import GeneralConfig from '../config/general-config';
import HttpMethod from '../constants/http-method-constant';
import HttpStatus from '../constants/http-status-code-constant';
import ErrorDetails from '../models/error-details';
import HttpResponseResult from '../models/http-response-result';
import StringKeyObject from '../models/string-key-object';
import Token from '../models/token';
import CommonService from './common-service';
import ConfigService from './config-service';
import HttpResponseCodeManager from './http-response-code-manager';
import TokenService from './token-service';

export default class HttpRequestService {
  private static baseUrl: string;
  private static isRefreshingToken: boolean = false;

  /**
   * Combines the provided base url and relative url. 
   * Does NOT use HttpInterceptor to achieve this goal because we want to 
   * make the code logic clear and in one place.
   * @param baseUrl The base url for current http request.
   * @param url The relative url string.
   * @returns The full url. 
   */
  static combineUrl(baseUrl: string, url: string): string {
    if (baseUrl && baseUrl !== '') {
      // Converts the input url to lower case just in case.
      // url = url.toLowerCase();

      // Checks if the input url already has a leading slash.
      if (!url.startsWith('/')) {
        // Appends the leading slash if needed. 
        url = '/' + url;
      }

      // Checks if the input url already starts with the base url.
      if (url.startsWith(baseUrl)) {
        // If already contains the base url.
        return url;
      } else {
        // Appends the base url if needed.
        return baseUrl + url;
      }
    } else {
      return url;
    }
  }

  /**
   * Combines the relative url with default base url. 
   * @param url The relative url string.
   * @returns The full url. 
   */
  static async combineUrlWithDefault(url: string): Promise<string> {
    await this.loadBaseUrl();
    return this.combineUrl(this.baseUrl, url);
  }

  /**
   * Makes the original http request based on [httpMethod] which does not attach any default header and parameter.
   * This is suitable for loading local json file or making a request to another remote system url.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @returns The HttpResponseResult instance.
   */
  static async makeOriginalRequest(httpMethod: string, url: string, data: StringKeyObject = {}): Promise<HttpResponseResult> {
    let response: any;
    
    try {
      // if (httpMethod === HttpMethod.get) {
      //   response = await axios.get(url, {
      //     params: data,
      //   });
      // } else if (httpMethod === HttpMethod.post) {
      //   response = await axios.post(url, {
      //     params: data,
      //   });
      // }
      response = await this.requst(url, httpMethod, {}, data);
    } catch (e: any) {
      response = e.response;
    }
    return {
      success: true,
      payload: response!.data,
    };
  }

  /**
   * Gets the remote server data.
   * @param url The remote server relative url. The base url will be appended automatically.
   * @param params The parameters for getting data.
   * @returns The HttpResponseResult instance.
   */
  static async get(url: string, params: StringKeyObject = {}): Promise<HttpResponseResult> {
    await this.loadBaseUrl();
    return this.makeRequestWithTokenCheck(HttpMethod.get, this.baseUrl, url, params);
  }

  /**
   * Posts the data to the remote server.
   * @param url The remote server relative url. The base url will be appended automatically.
   * @param body The payload body data for post request.
   * @param options The request options which can overwrite predefined one.
   * @returns The HttpResponseResult instance.
   */
  static async post(url: string, body: StringKeyObject = {}): Promise<HttpResponseResult> {
    await this.loadBaseUrl();

    return this.makeRequestWithTokenCheck(HttpMethod.post, this.baseUrl, url, body);
  }

  /**
   * Loads base url from config if needed.
   * @returns Promise<string> of base url.
   */
  static async loadBaseUrl(): Promise<string> {
    if (CommonService.isNullOrUndefinedOrEmptyString(this.baseUrl)) {
      this.baseUrl = (await ConfigService.getLocalGeneralConfig()).httpBaseUrl;
    }

    return this.baseUrl;
  }

  /**
   * Creates the authroization http request header.
   */
  static createAuthorizationRequestHeader(): StringKeyObject {
    let header: any = {};

    if (TokenService.hasAccessToken) {
      header['authorization'] = `${TokenService.bearer} ${TokenService.activeAccessToken}`;
    }

    return header;
  }

  /**
   * Creates the customized http request header.
   */
  static createRequestHeader(): StringKeyObject {
    let header = this.createAuthorizationRequestHeader();
    header['content-type'] = 'application/json';

    return header;
  }

  /**
  * Check if need to refresh the token based on the current requesting url.
  * @param url The string of current requesting url.
  * @returns True means need to refresh the token. False means NO such need.
  */
  static needToRefreshToken(url: string): boolean {
    return GeneralConfig.noTokenRefreshNeedUrls.filter((p) => url.endsWith(p)).length === 0
  }

  /**
   * Makes the http request.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param baseUrl The string of http request base url.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @returns The HttpResponseResult instance.
   */
  static async makeRequest(httpMethod: string, baseUrl: string, url: string, data: StringKeyObject = {}): Promise<any> {
    let response: any;
    let header = this.createRequestHeader();

    // const config: AxiosRequestConfig = {
    //   baseURL: baseUrl,
    //   headers: header,
    //   timeout: (await ConfigService.getLocalGeneralConfig()).httpRequestTimeoutInSecond * 1000,
    // };

    try {
      // if (httpMethod === HttpMethod.get) {
      //   response = await axios.get(url, { ...config, params: data });
      // } else if (httpMethod === HttpMethod.post) {
      //   response = await axios.post(url, data, config);
      // }
      response = await this.requst(baseUrl + url, httpMethod, header, data);
    } catch (e: any) {
      response = e.response;

      // Connection timeout.
      if (e.code === 'ECONNABORTED') {
        console.error(e.message);
      }
    }
    
    return response!;
  }

  /**
   * Processes the http request by attaching it with other operators. This method will check the local token.
   * If the local access token expired the refresh token request will be raised before the user request.
   * Once the new access token is retrieved the user request will contiune.
   * @param httpMethod The string of the http method. Use HttpMethodConstant as the parameter.
   * @param baseUrl The string of http request base url.
   * @param url The string of current requesting url.
   * @param data The params for http get and body for http post.
   * @param baseUrl The string of http request base url.
   */
  static async makeRequestWithTokenCheck(httpMethod: string, baseUrl: string, url: string, data: StringKeyObject = {}): Promise<HttpResponseResult> {
    let response: any;
  
    if (TokenService.hasAccessToken &&
      TokenService.hasRefreshToken &&
      TokenService.isAccessTokenExpired &&
      this.needToRefreshToken(url)) {
      if (!this.isRefreshingToken) {
        this.isRefreshingToken = true;

        try {
          // Tries to refresh the token.
          await this.refreshToken();
        } finally {
          this.isRefreshingToken = false;
        }

        // If refreshing token process is done then we nee to retry the original request.
        return this.makeRequestWithTokenCheck(httpMethod, baseUrl, url, data);
      } else {
        const waitForRefreshingTokenInMillisecond = (await ConfigService.getLocalGeneralConfig()).waitForRefreshingTokenInMillisecond;

        // If token is refresing then current request has to wait until the new one is ready.
        while (this.isRefreshingToken) {
          await CommonService.delay(waitForRefreshingTokenInMillisecond);
        }

        // Contiunes the original request.
        return this.makeRequestWithTokenCheck(httpMethod, baseUrl, url, data);
      }
    } else {
      response = await this.makeRequest(httpMethod, baseUrl, url, data);

      return this.checkHttpResponse(response);
    }
  }

  /**
   * wx.request 封装，用于支持 Promise 风格
   * @param url 请求url
   * @param httpMethod POST | GET
   * @param header 请求头
   * @param data 请求参数
   */
  static requst(url: any, httpMethod: any, header: any, data: any): Promise<any> {
    return new Promise((resolve: any, reject: any) => {
      wx.request({
        url,
        method: httpMethod,
        header,
        data,
        success(response) {
          resolve(response);
        },
        fail(response) {
          reject(response);
        },
      });
    });
  }

  /**
   * Refreshes the token if it can. It returns [true] if the refreshing is successful.
   */
  static async refreshToken(): Promise<void> {
    // The uri for refreshing token.
    const url = await this.combineUrlWithDefault('/authentication/refreshToken');

    // TODO: Applies real deviceInfo & systemInfo as needed.
    // Prepares the post body.
    const body = {
      refreshToken: TokenService.activeRefreshToken,
      deviceInfo: {},
      systemInfo: {},
    }

    // Makes http post to refresh the token.
    // const response: AxiosResponse = await axios.post(url, body);
    const response = await this.requst(url, 'POST', {}, body)
    
    if (response.statusCode === HttpStatus.ok) {
      const token = await Token.fromServerJson(response.data.payload);

      // Sets the new token retrieved from remote server.
      TokenService.saveToken(token);
    }
  }

  /**
   * Checks on [response] to see if it is successful or not.
   * 
   * IMPORTANT!!!
   * If this method is not async then throwing error from here will not be caught by [runZonedGuarded] in main.dart.
   * 
   * If it is successful then we will convert it to [Map<String, dynamic>] format.
   * If the status code is [HttpStatus.unauthorized] we will try to refresh the access token if we can.
   */
  static async checkHttpResponse(response: any): Promise<HttpResponseResult> {
    let httpResponseResult: HttpResponseResult;
    
    // Checks if the response is successful or not.
    switch (response?.statusCode) {
      case HttpStatus.ok:
      case HttpStatus.noContent:
        httpResponseResult = response.data;
        break;
      case HttpStatus.unauthorized:
        this.handleUnauthorizedError(response);
        break;
      case HttpStatus.forbidden:
        this.showMessage('抱歉，您被禁止该访问。');
        break;
      default:
        this.handleGeneralError(response);
        break;
    }

    httpResponseResult ??= new HttpResponseResult(false);

    return httpResponseResult;
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  static handleUnauthorizedError(response: any): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);

    if (!CommonService.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      // Displays message to user.
      // Actually we did not display message like 'user not found' or 'password is incorrect'.
      // This is for void brute force attack. If the attacker does NOT know the login id or password is correct that would make the attach more difficult.
      this.showMessage(errorDetails!.message!);
    } else {
      this.showMessage('登录已过期或没有访问权限，请重新登录。');
    }

    this.logUserOut();
  }

  /**
   * By now the spring security rest returns [HttpStatus.unauthorized] for different reasons.
   * We need to check into the header to see what exactly happened.
   * Handles the error of the [HttpStatus.unauthorized].
   */
  static handleGeneralError(response: any): void {
    const errorDetails = this.getBodyErrorDetails(response) ?? this.getHeaderErrorDetails(response);
    let errorMessage = '服务处理发生问题';

    if (!CommonService.isNullOrUndefinedOrEmptyString(errorDetails?.message)) {
      errorMessage += `: ${errorDetails?.message}。`;
    }

    this.showMessage(errorMessage);
  }

  /**
   * Gets the response error from body.
   */
  static getBodyErrorDetails(response?: any): ErrorDetails | null {
    return this.getErrorDetails('message', response?.data);
  }

  /**
   * Gets the response error from header.
   */
  static getHeaderErrorDetails(response?: any): ErrorDetails | null {
    return this.getErrorDetails('Error-Code', response?.headers);
  }

  /**
   * Gets the error message from [data] which might be the response body or header.
   * The [key] which used to find in the map to see if it contains error code.
   */
  static getErrorDetails(key: string, data?: StringKeyObject | null): ErrorDetails | null {
    if (data) {
      let errorMessage: string | null;
      let errorCode: string | null;

      // Tries to get the error key from http header if the header key contains [key].
      // Uses orElse parameter to slient 'No Element' error if no match found.
      let errorKey = Object.keys(data).find((p) => p.toLowerCase().toLowerCase().includes(key));

      // If error code exists.
      if (!CommonService.isNullOrUndefinedOrEmptyString(errorKey)) {
        // Gets the code by the key.
        errorCode = data[errorKey!];

        // Gets the message by code.
        errorMessage = HttpResponseCodeManager.getCodeMessage(errorCode!);

        return {
          code: errorCode,
          message: errorMessage,
        };
      }
    }

    return null;
  }

  /// Shows message to user if anything goes wrong.
  static showMessage(msg: string): void {
    // TODO: Replaces with real message show.
    console.info(msg);
  }

  static logUserOut(): void { }
}